SPB Git

Repository Public

page.tsx 8.6 KB 214 lines tsx

1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// /admin/courriel/[id] — lecture d'un courriel, version rehaussée : en-tête3// avec avatar du correspondant, métadonnées propres, corps typographié,4// pièces jointes, actions et réponse inline. Le HTML entrant n'est JAMAIS5// rendu brut — texte extrait seulement. L'ouverture marque le message lu.6// Accès : propriétaire de la boîte, ou super admin pour les non-routés.7import { notFound, redirect } from "next/navigation";8import { getAdminSession } from "@/lib/comms/admin-auth";9import { findAdminById, findSubmissionById } from "@/lib/comms/db";10import {11  findMail,12  canAccessMail,13  updateMail,14  mailAttachmentsOf,15  MAIL_FOLDERS,16} from "@/lib/comms/mail";17import { hydrateInboundMail } from "@/lib/comms/resend-fetch";18import { MailComposer, MailActions } from "../MailClient";19import { timeAgo, fmtDateFull, initialsOf, avatarStyle } from "../../format";2021export const dynamic = "force-dynamic";2223function fmtBytes(n: number): string {24  if (n < 1024) return `${n} o`;25  if (n < 1024 * 1024) return `${Math.round(n / 1024)} Ko`;26  return `${(n / 1024 / 1024).toFixed(1)} Mo`;27}2829/** HTML entrant : on n'affiche JAMAIS le HTML brut — texte extrait seulement. */30function htmlToText(html: string): string {31  return html32    .replace(/<style[\s\S]*?<\/style>/gi, " ")33    .replace(/<script[\s\S]*?<\/script>/gi, " ")34    .replace(/<br\s*\/?>/gi, "\n")35    .replace(/<\/(p|div|tr|li|h[1-6])>/gi, "\n")36    .replace(/<[^>]+>/g, "")37    .replace(/&nbsp;/g, " ")38    .replace(/&amp;/g, "&")39    .replace(/&lt;/g, "<")40    .replace(/&gt;/g, ">")41    .replace(/\n{3,}/g, "\n\n")42    .trim();43}4445export default async function MailViewPage({46  params,47}: {48  params: Promise<{ id: string }>;49}) {50  const session = await getAdminSession();51  if (!session) redirect("/admin/connexion");52  const id = Number((await params).id);53  const mail = Number.isInteger(id) ? findMail(id) : undefined;54  const superAdmin = session.roles.includes("super_admin");55  if (!mail || !canAccessMail(mail, session.id, superAdmin)) notFound();5657  if (!mail.is_read) updateMail(mail.id, { is_read: true });5859  // Rattrapage : un entrant sans corps (webhook = métadonnées seulement)60  // est hydraté via l'API Resend à l'ouverture — si une clé lisante existe.61  let bodyText = mail.body_text;62  let bodyHtml = mail.body_html;63  let hydrationFailed = false;64  if (mail.direction === "in" && !bodyText && !bodyHtml) {65    const hydrated = await hydrateInboundMail(mail).catch(() => null);66    if (hydrated) {67      bodyText = hydrated.text;68      bodyHtml = hydrated.html;69    } else hydrationFailed = true;70  }7172  const atts = mailAttachmentsOf(mail.id);73  const admin = findAdminById(session.id);74  const to = JSON.parse(mail.to_emails) as string[];75  const cc = mail.cc_emails ? (JSON.parse(mail.cc_emails) as string[]) : [];76  const body =77    bodyText?.trim() || (bodyHtml ? htmlToText(bodyHtml) : "");78  const linked = mail.submission_id ? findSubmissionById(mail.submission_id) : undefined;79  const isOut = mail.direction === "out";80  const counterpartEmail = isOut ? (to[0] ?? "?") : mail.from_email;8182  return (83    <div className="mx-auto max-w-4xl">84      <nav aria-label="Fil d'Ariane" className="flex items-center justify-between gap-3">85        <a86          href={`/admin/courriel?folder=${mail.folder}`}87          className="gk-mono text-[11px] font-bold tracking-[0.1em] text-ink-3 uppercase no-underline hover:text-green"88        >89          ← {MAIL_FOLDERS[mail.folder] ?? "Courriel"}90        </a>91        <span className="gk-mono text-[10px] text-ink-3" title={fmtDateFull(mail.created_at)}>92          {timeAgo(mail.created_at)}93        </span>94      </nav>9596      <div className="gk-card mt-4 overflow-hidden">97        {/* en-tête correspondant */}98        <div className="flex flex-wrap items-center gap-4 border-b-[1.5px] border-ink bg-surface-2 px-5 py-4 sm:px-7">99          <span className="adm-avatar !h-[48px] !w-[48px] !text-[17px]" style={avatarStyle(counterpartEmail)}>100            {initialsOf(isOut ? null : mail.from_name, counterpartEmail)}101          </span>102          <div className="min-w-0 flex-1">103            <p className="truncate text-[15px] font-bold">104              {isOut105                ? `Vous → ${to.join(", ")}`106                : mail.from_name107                  ? `${mail.from_name}`108                  : mail.from_email}109            </p>110            <p className="gk-mono mt-[1px] truncate text-[10.5px] text-ink-3">111              {isOut ? `de ${mail.from_email}` : mail.from_email}112              {cc.length ? ` · cc ${cc.join(", ")}` : ""}113              {!isOut ? ` · à ${to.join(", ")}` : ""}114            </p>115          </div>116          <div className="flex flex-none flex-wrap gap-2">117            <span className="adm-chip adm-chip--ghost !text-[9px]">118              {isOut ? "Envoyé" : "Reçu"}119            </span>120            {mail.admin_id === null ? (121              <span className="adm-chip adm-chip--haute !text-[9px]">Non routé</span>122            ) : null}123          </div>124        </div>125126        {/* sujet + demande liée */}127        <div className="px-5 pt-5 sm:px-7">128          <h1 className="gk-display text-[clamp(19px,2.6vw,26px)] leading-tight font-bold tracking-[-0.03em]">129            {mail.subject || "(sans sujet)"}130          </h1>131          {linked ? (132            <p className="mt-3 inline-flex flex-wrap items-center gap-2 rounded-lg border-[1.5px] border-green bg-lime-soft px-3 py-2 text-[12px]">133              <span className="gk-mono font-bold tracking-[0.06em] text-green uppercase">Demande liée</span>134              <a href={`/admin/demande/${linked.reference}`} className="gk-mono font-bold text-ink underline underline-offset-4">135                {linked.reference}136              </a>137              <span className="text-ink-2">— aussi versé dans sa conversation</span>138            </p>139          ) : null}140        </div>141142        {/* corps */}143        <div className="px-5 py-5 sm:px-7 sm:py-6">144          {body ? (145            <p className="max-w-[68ch] text-[15px] leading-[1.75] whitespace-pre-wrap">{body}</p>146          ) : hydrationFailed ? (147            <div className="rounded-lg border-[1.5px] border-amber bg-amber-soft px-4 py-3">148              <p className="gk-mono text-[10.5px] font-bold tracking-[0.08em] text-[#7a4d0d] uppercase">149                Contenu non transmis par le webhook Resend150              </p>151              <p className="mt-2 text-[13px] leading-relaxed text-ink-2">152                Resend ne livre que les métadonnées dans l&apos;événement — le153                corps se lit via son API, qui exige une clé avec accès154                lecture. Créez une clé «&nbsp;Full access&nbsp;» dans le155                dashboard Resend, collez-la dans{" "}156                <code className="gk-mono text-[11.5px] font-bold">RESEND_READ_API_KEY</code>{" "}157                (.env.local) puis redémarrez : ce message sera récupéré158                automatiquement à sa prochaine ouverture.159              </p>160            </div>161          ) : (162            <p className="gk-mono text-[12px] text-ink-3">(message vide)</p>163          )}164        </div>165166        {/* pièces jointes */}167        {atts.length ? (168          <div className="border-t border-line px-5 py-4 sm:px-7">169            <p className="klabel">Pièces jointes ({atts.length})</p>170            <ul className="mt-2 flex flex-wrap gap-2">171              {atts.map((a) => (172                <li key={a.id}>173                  <a174                    href={`/api/admin/mail/attachments/${a.id}`}175                    className="gk-mono inline-flex items-center gap-2 rounded-lg border-[1.5px] border-ink bg-surface px-3 py-2 text-[11.5px] font-bold no-underline hover:bg-lime-soft"176                  >177                    ⭳ {a.original_name}178                    <span className="font-normal text-ink-3">{fmtBytes(a.size)}</span>179                  </a>180                </li>181              ))}182            </ul>183          </div>184        ) : null}185186        {/* barre d'actions */}187        <div className="border-t-[1.5px] border-ink bg-surface-2 px-5 py-3 sm:px-7">188          <MailActions189            id={mail.id}190            folder={mail.folder}191            isRead={true}192            unrouted={mail.admin_id === null}193          />194        </div>195      </div>196197      {/* réponse */}198      {mail.direction === "in" ? (199        <div className="mt-5">200          <MailComposer201            fromEmail={admin?.email ?? ""}202            replyTo={{203              id: mail.id,204              to: mail.from_email,205              subject: mail.subject ?? "",206            }}207            startOpen={false}208          />209        </div>210      ) : null}211    </div>212  );213}214
Files main
Menu

Theme